mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 20:02:55 +08:00
fix(pi-permission-inner-cmd): unwrap timeout in real-world command forms
- accept GNU timeout durations without a unit suffix and with decimals (timeout 240 …) - detect the wrapper on details.command and strip it from the full command so scaffolded inputs (cd … && timeout … | tail) unwrap - re-evaluate the full de-wrapped compound so sibling commands cannot hide behind the wrapper allow - defer fail-closed when the unit is not a unique substring of the full command - amend ADR 0001 with the relaxed grammar and the scaffolded-command handling
This commit is contained in:
@@ -1,56 +1,128 @@
|
||||
import { classifyWrapper, isRecognizedWrapper } from "../recognizer";
|
||||
import {
|
||||
isRecognizedWrapper,
|
||||
parseTimeoutWrapper,
|
||||
TIMEOUT_PREFIX,
|
||||
} from "../recognizer";
|
||||
import type { CommandHandler } from "./types";
|
||||
|
||||
/** Bash permission surface queried when re-evaluating the inner command. */
|
||||
const BASH_SURFACE = "bash";
|
||||
|
||||
/**
|
||||
* The strict simple-timeout wrapper handler (ADR 0001).
|
||||
* Replace the wrapper unit with its unwrapped inner inside the full command,
|
||||
* exactly once. Returns `undefined` when the unit is not a unique substring
|
||||
* (absent, or appears more than once), so the caller defers fail-closed rather
|
||||
* than guess where to strip.
|
||||
*/
|
||||
function stripWrapperUnit(
|
||||
fullCommand: string,
|
||||
unit: string,
|
||||
inner: string,
|
||||
): string | undefined {
|
||||
const first = fullCommand.indexOf(unit);
|
||||
if (first === -1) {
|
||||
return undefined;
|
||||
}
|
||||
if (fullCommand.indexOf(unit, first + unit.length) !== -1) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
fullCommand.slice(0, first) +
|
||||
inner +
|
||||
fullCommand.slice(first + unit.length)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The simple-timeout wrapper handler (ADR 0001).
|
||||
*
|
||||
* Unwraps `timeout <duration> <command>`, rejects nested wrappers, and
|
||||
* re-evaluates the complete inner program through the deterministic policy.
|
||||
* Unsupported timeout syntax and nested wrappers defer with a debug log.
|
||||
* Commands that are not timeout at all return `undefined` so the engine can try
|
||||
* the next handler.
|
||||
* Detection runs on `details.command` — the command unit the permission system
|
||||
* isolated as the ask trigger — which is always wrapper-leading even when the
|
||||
* full recovered command is a scaffold that starts with `cd`/`echo`/…. The
|
||||
* wrapper is then stripped from the FULL command and the whole de-wrapped
|
||||
* compound is re-evaluated, so sibling commands (including dangerous ones) are
|
||||
* still judged and cannot hide behind the wrapper's allow.
|
||||
*
|
||||
* Unsupported timeout syntax, a nested wrapper, a unit that cannot be located
|
||||
* exactly once in the full command, and any non-allowing re-evaluation all
|
||||
* defer fail-closed.
|
||||
*/
|
||||
export const timeoutHandler: CommandHandler = {
|
||||
id: "timeout",
|
||||
decide(ctx) {
|
||||
const { command, details, query, log, evidence } = ctx;
|
||||
const classification = classifyWrapper(command);
|
||||
switch (classification.kind) {
|
||||
case "nonTimeout":
|
||||
return undefined;
|
||||
case "unsupportedTimeout":
|
||||
log.debug("inner_cmd.unsupported_timeout_syntax", { command });
|
||||
const { command: fullCommand, details, query, log, evidence } = ctx;
|
||||
const unit = details.command;
|
||||
if (unit === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const unitMatch = parseTimeoutWrapper(unit);
|
||||
if (unitMatch === undefined) {
|
||||
// Not the recognized form. If it still names `timeout`, surface it
|
||||
// as unsupported; otherwise this unit is not ours.
|
||||
if (TIMEOUT_PREFIX.test(unit)) {
|
||||
log.debug("inner_cmd.unsupported_timeout_syntax", {
|
||||
command: fullCommand,
|
||||
});
|
||||
return { kind: "defer" };
|
||||
case "recognized": {
|
||||
const innerCommand = classification.match.innerCommand;
|
||||
// Record the derived inner command so the engine's exception
|
||||
// log retains it if the re-evaluation below throws.
|
||||
evidence.innerCommand = innerCommand;
|
||||
if (isRecognizedWrapper(innerCommand)) {
|
||||
log.debug("inner_cmd.nested_timeout", { command, innerCommand });
|
||||
return { kind: "defer" };
|
||||
}
|
||||
const result = query.checkPermission(
|
||||
BASH_SURFACE,
|
||||
innerCommand,
|
||||
details.agentName ?? undefined,
|
||||
);
|
||||
switch (result.state) {
|
||||
case "allow":
|
||||
log.review("inner_cmd.allow", { command, innerCommand });
|
||||
return { kind: "allow" };
|
||||
case "deny":
|
||||
log.review("inner_cmd.deny", { command, innerCommand });
|
||||
return { kind: "deny" };
|
||||
case "ask":
|
||||
default:
|
||||
log.debug("inner_cmd.inner_ask", { command, innerCommand });
|
||||
return { kind: "defer" };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const innerCommand = unitMatch.innerCommand;
|
||||
evidence.innerCommand = innerCommand;
|
||||
|
||||
// Never unwrap into another wrapper.
|
||||
if (isRecognizedWrapper(innerCommand)) {
|
||||
log.debug("inner_cmd.nested_timeout", {
|
||||
command: fullCommand,
|
||||
innerCommand,
|
||||
});
|
||||
return { kind: "defer" };
|
||||
}
|
||||
|
||||
// Strip the wrapper from the full command (handles scaffolds). Defer
|
||||
// fail-closed if the unit is not a unique substring.
|
||||
const unwrappedFull = stripWrapperUnit(
|
||||
fullCommand,
|
||||
unit,
|
||||
innerCommand,
|
||||
);
|
||||
if (unwrappedFull === undefined) {
|
||||
log.debug("inner_cmd.wrapper_not_located", {
|
||||
command: fullCommand,
|
||||
});
|
||||
return { kind: "defer" };
|
||||
}
|
||||
|
||||
// Authoritative: re-evaluate the full de-wrapped compound. The
|
||||
// permission system decomposes it into units and keeps the most
|
||||
// restrictive, so any non-allowing sibling defers here.
|
||||
const result = query.checkPermission(
|
||||
BASH_SURFACE,
|
||||
unwrappedFull,
|
||||
details.agentName ?? undefined,
|
||||
);
|
||||
switch (result.state) {
|
||||
case "allow":
|
||||
log.review("inner_cmd.allow", {
|
||||
command: fullCommand,
|
||||
innerCommand,
|
||||
});
|
||||
return { kind: "allow" };
|
||||
case "deny":
|
||||
log.review("inner_cmd.deny", {
|
||||
command: fullCommand,
|
||||
innerCommand,
|
||||
});
|
||||
return { kind: "deny" };
|
||||
case "ask":
|
||||
default:
|
||||
log.debug("inner_cmd.inner_ask", {
|
||||
command: fullCommand,
|
||||
innerCommand,
|
||||
});
|
||||
return { kind: "defer" };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
/**
|
||||
* V0.1 wrapper recognizer.
|
||||
*
|
||||
* The strict simple-timeout grammar from ADR 0001. V0.1 unwraps exactly
|
||||
* The simple-timeout grammar from ADR 0001. V0.1 unwraps exactly
|
||||
* `timeout <duration> <command>`; every other `timeout` invocation is left to
|
||||
* the next authority.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Matches `timeout <duration> <command>` where `<duration>` is a positive
|
||||
* integer (no leading zero) followed by a single unit `s`/`m`/`h`/`d`.
|
||||
* Matches `timeout <duration> <command>` where `<duration>` follows GNU
|
||||
* timeout's grammar: a positive number (integer or decimal, no leading zero)
|
||||
* with an optional unit `s`/`m`/`h`/`d` (default seconds). A bare integer such
|
||||
* as `timeout 240 cmd` is therefore accepted (240 seconds).
|
||||
*
|
||||
* Flags (`-k`, `--preserve-status`, GNU `--`), compound durations, and the
|
||||
* bare form are intentionally excluded so v0.1 never unwraps a wrapper it
|
||||
* cannot re-evaluate safely.
|
||||
* The duration format is irrelevant to unwrap soundness — the duration is
|
||||
* discarded and only the inner command is re-evaluated — so GNU's full numeric
|
||||
* grammar is accepted. Still excluded: `0`/leading-zero durations, `ms` (not a
|
||||
* timeout unit), multi-letter units, and flags (`-k`, `--preserve-status`, GNU
|
||||
* `--`), so a wrapper that cannot be re-evaluated safely is never unwrapped.
|
||||
*/
|
||||
const TIMEOUT_WRAPPER_PATTERN = /^timeout[ \t]+([1-9][0-9]*[smhd])[ \t]+(.+)$/;
|
||||
const TIMEOUT_WRAPPER_PATTERN =
|
||||
/^timeout[ \t]+([1-9][0-9]*(?:\.[0-9]+)?[smhd]?)[ \t]+(.+)$/;
|
||||
|
||||
/** A command that begins with the bare `timeout` wrapper program. */
|
||||
const TIMEOUT_PREFIX = /^timeout(?:[ \t]|$)/;
|
||||
export const TIMEOUT_PREFIX = /^timeout(?:[ \t]|$)/;
|
||||
|
||||
export interface TimeoutWrapperMatch {
|
||||
readonly duration: string;
|
||||
|
||||
Reference in New Issue
Block a user