Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ OpsQuest validates the result, not a prescribed command. Equivalent supported so
- 23 Linux missions across four ordered learning worlds
- 16 optional, disposable Docker missions across three worlds: It Works on My Machine, Container Triage, and Network Plumbing
- An isolated in-memory filesystem, environment, process table, and archives
- Quote-aware globs and variables, pipelines, redirection, and command history
- Quote-aware globs and variables, pipelines, redirection, `;`/`&&`/`||` command lists, and command history
- A compact virtual `vi` and bounded virtual shell scripts
- Progressive hints, free command guidance, XP, ranks, and six achievements
- An optional loopback-only web companion for mission guidance and live progress
Expand Down
18 changes: 14 additions & 4 deletions docs/play/controls-and-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,24 @@ Completion reads only the active environment's command vocabulary and virtual fi
## Teaching-shell command set

```text
awk basename cat cd chmod chown clear cp cut dirname du echo env export find
awk basename cat cd chmod chown clear cp cut dirname du echo env export false find
grep gzip gunzip head help history kill less ls man mkdir mv printf ps pwd rm
rmdir sed sh sort stat tail tar touch tr uniq vi wc whoami
rmdir sed sh sort stat tail tar touch tr true uniq vi wc whoami
```

The shell also supports quote-aware variables and globs, pipelines (`|`), input redirection (`<`), and output redirection (`>` and `>>`). It intentionally implements a teaching subset of each command. Use `help COMMAND` for the exact supported flags and examples.

Enter one command per line. Command lists (`;`, `&&`, `||`), background jobs (`&`), subshells, command substitution, special parameters such as `$?`, and file-descriptor redirection such as `2>` are rejected with an explanation before anything runs. Quote or escape these characters to use them as data.
Command lists join commands on one line, as in `sh`:

- `a; b` always runs `b`.
- `a && b` runs `b` only when `a` succeeds.
- `a || b` runs `b` only when `a` fails.

`&&` and `||` bind equally from left to right, so `a && b || c` runs `c` when either `a` or `b` fails. Each command expands variables and globs when its turn comes, so `cd logs && ls *` lists the new directory. A failure is shown, then the list continues as its operators allow. `grep` without a match fails silently, which makes `grep -q ERROR app.log && echo alert` work; `true` and `false` set a status and nothing else.

Output redirection with `>` empties its file before the command runs, so `sort notes.txt > notes.txt` leaves an empty file, just as on Linux. If the command fails, the file is put back. Wildcards skip hidden names unless the pattern starts with a dot: `*` ignores `.env`, while `.*` matches it.

Background jobs (`&`), subshells, command substitution, special parameters such as `$?`, and file-descriptor redirection such as `2>` are rejected with an explanation before anything runs. Quote or escape these characters to use them as data.

## Mission navigation

Expand Down Expand Up @@ -74,6 +84,6 @@ opsquest:/workspace$ ./report.sh

`sh FILE` does not require executable permission. Direct paths require an executable mode and a supported shell shebang. Files, archives, and mission processes retain changes, while a script's working directory and exported environment are restored when it returns.

This is not a complete POSIX shell. Loops, conditionals, functions, substitutions, background jobs, external programs, positional arguments, `sh -c`, stdin-fed source, and interactive editor calls from scripts are rejected.
Command lists work in scripts too: a failure handled by `||` is reported with its script location and the script continues, and a script's exit status is its last command's. This is not a complete POSIX shell. Loops, `if` conditionals, functions, substitutions, background jobs, external programs, positional arguments, `sh -c`, stdin-fed source, and interactive editor calls from scripts are rejected.

For implementation limits and trust boundaries, see [Sandbox and safety](../technical/sandbox-and-safety.md).
7 changes: 5 additions & 2 deletions docs/play/missions-and-progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ may unlock achievements. Replays retain the original completion and reward.

Every successful teaching command counts toward command mastery as soon as it
runs, including during replays. Lab utilities such as `help`, `man`, `clear`,
and `history` are free to use and do not count.
`history`, `true`, and `false` are free to use and do not count.

The displayed level is derived from total XP. Ranks progress through:

Expand All @@ -73,7 +73,10 @@ The displayed level is derived from total XP. Ranks progress through:
3. Junior Sysadmin
4. Sysadmin
5. SRE
6. Senior SRE
6. Senior SRE (1,100 XP)
7. Staff SRE (1,600 XP, about the whole Linux track)
8. Principal SRE (2,150 XP)
9. Distinguished Engineer (2,700 XP, most of the Docker track as well)

Achievements recognize the first fix, a three-command pipeline, ten practiced
commands, five hint-free completions, an advanced incident, and completion of
Expand Down
19 changes: 10 additions & 9 deletions docs/technical/sandbox-and-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,19 @@ Editable source: [`command-execution-pipeline.mmd`](diagrams/command-execution-p
[`Sandbox.Execute`](https://github.com/Aleksandergreg/go-cli-tool/blob/main/internal/sandbox/shell.go) processes a line in explicit stages:

1. **Bound input:** reject a command line over 64 KiB before adding it to the attempt's 100-entry history.
2. **Lex:** reject unquoted shell syntax outside the teaching subset (command lists, background jobs, subshells, substitutions, special parameters, and file-descriptor redirection), then recognize words, quotes, escapes, comments, variables, pipes, and `<`, `>`, or `>>`. Interactive and script lines share these rules. Expansion reads only the sandbox environment.
3. **Parse:** build pipeline stages and attach at most one input and output redirection to each stage.
4. **Expand:** resolve eligible globs against the virtual filesystem and enforce expanded token and argument budgets.
5. **Preflight compositions:** reject unsupported interactive-editor or script placement before an earlier pipeline stage can mutate state.
6. **Dispatch:** call a Go method from the supported-command switch. Nested `find -exec` and scripts share dispatch budgets.
7. **Move virtual data:** pipeline output becomes the next stage's input; redirection reads or writes only virtual files.
8. **Return learning metadata:** output, successful command names, maximum pipeline width, or a virtual editor request flow back to `game.Session`.
9. **Observe outcomes:** output validators compare the returned text; state validators query the active environment.
2. **Split lists:** separate unquoted `;`, `&&`, and `||` into pipelines and check every pipeline's syntax before the first runs. Each pipeline then passes through the remaining stages only when its turn comes, so expansion sees earlier `cd` and `export` effects.
3. **Lex:** reject unquoted shell syntax outside the teaching subset (background jobs, subshells, substitutions, special parameters, and file-descriptor redirection), then recognize words, quotes, escapes, comments, variables, pipes, and `<`, `>`, or `>>`. Interactive and script lines share these rules. Expansion reads only the sandbox environment.
4. **Parse:** build pipeline stages and attach at most one input and output redirection to each stage.
5. **Expand:** resolve eligible globs against the virtual filesystem and enforce expanded token and argument budgets.
6. **Preflight compositions:** reject unsupported interactive-editor or script placement before an earlier pipeline stage can mutate state.
7. **Dispatch:** call a Go method from the supported-command switch. Every pipeline of a list, nested `find -exec`, and scripts share one dispatch budget, and a list's combined output shares the 2 MiB output limit.
8. **Move virtual data:** pipeline output becomes the next stage's input; redirection reads or writes only virtual files. A stage's `>` target is emptied just before it runs and restored if that stage fails.
9. **Return learning metadata:** output, successful command names, maximum pipeline width, an ordered output-and-error transcript for lists, or a virtual editor request flow back to `game.Session`.
10. **Observe outcomes:** output validators compare the returned text; state validators query the active environment.

Unsupported commands fail at dispatch. The shell implements a teaching subset, not process lookup, so a name absent from the dispatcher can never fall through to the host.

A full pipeline is not one transaction: an ordinary earlier stage may mutate virtual state before a later stage fails. Safety-sensitive operations preflight their own affected state, and compositions known to be unsupported (`vi` in a pipeline, or a script receiving pipeline/file input) are rejected before any stage runs.
A full pipeline is not one transaction: an ordinary earlier stage may mutate virtual state before a later stage fails. Safety-sensitive operations preflight their own affected state, a failed stage restores the redirect target it emptied, and compositions known to be unsupported (`vi` in a pipeline or list, or a script receiving pipeline/file input) are rejected before any stage runs. A command list is likewise not a transaction: commands that ran before a failure keep their effects.

## Virtual state model

Expand Down
11 changes: 11 additions & 0 deletions internal/game/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ type Execution struct {
PracticedCommands []string
PipelineWidth int
Interactive *InteractiveAction
// Transcript orders output and error messages when one line ran several
// commands (a; b, a && b, a || b). When it is empty, Output is the whole
// display. Output always holds only standard output for validators.
Transcript []TranscriptEntry
}

// TranscriptEntry is command output, or an error message shown on the
// player's error stream.
type TranscriptEntry struct {
Text string
Error bool
}

// InteractiveAction lets an environment hand terminal-only work back to the
Expand Down
6 changes: 3 additions & 3 deletions internal/game/progression.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ func ReconcileAchievements(player *profile.Profile, catalog mission.Catalog, now
return unlocked
}

// metaCommands explain or tidy the lab rather than practice an operations
// skill, so they never earn command mastery.
var metaCommands = map[string]bool{"clear": true, "help": true, "history": true, "man": true}
// metaCommands explain or tidy the lab, or only set an exit status, rather
// than practice an operations skill, so they never earn command mastery.
var metaCommands = map[string]bool{"clear": true, "false": true, "help": true, "history": true, "man": true, "true": true}

// CountsAsPractice reports whether a successful command earns command mastery.
func CountsAsPractice(command string) bool { return !metaCommands[command] }
Expand Down
25 changes: 25 additions & 0 deletions internal/game/progression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,28 @@ func TestSessionDoesNotRecordMetaCommandsAsPractice(t *testing.T) {
t.Fatalf("completion did not list only teaching commands:\n%s", out.String())
}
}

// TestRanksSpanTheCatalog keeps rank thresholds meaningful as missions are
// added or removed: finishing Linux reaches Staff SRE, and only the Docker
// track leads to the top rank.
func TestRanksSpanTheCatalog(t *testing.T) {
catalog, err := mission.LoadCatalog()
if err != nil {
t.Fatal(err)
}
totals := map[string]int{}
for _, item := range catalog.All() {
totals[item.EffectiveTrack()] += item.Rewards.XP
}
linux := profile.Profile{XP: totals[mission.TrackLinux]}
if rank := linux.Rank(); rank != "Staff SRE" {
t.Errorf("Linux track total %d XP reaches %q, want Staff SRE", linux.XP, rank)
}
everything := profile.Profile{XP: totals[mission.TrackLinux] + totals[mission.TrackDocker]}
if rank := everything.Rank(); rank != "Distinguished Engineer" {
t.Errorf("catalog total %d XP reaches %q, want Distinguished Engineer", everything.XP, rank)
}
if _, _, hasNext := everything.NextRank(); hasNext {
t.Error("a rank remains beyond the whole catalog's XP")
}
}
3 changes: 3 additions & 0 deletions internal/game/sandbox_environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ func (e *sandboxEnvironment) Execute(ctx context.Context, line string) (Executio
PracticedCommands: slices.Clone(result.Commands),
PipelineWidth: result.PipelineWidth,
}
for _, chunk := range result.Transcript {
execution.Transcript = append(execution.Transcript, TranscriptEntry{Text: chunk.Text, Error: chunk.Error})
}
if result.Editor != nil {
request := *result.Editor
execution.Interactive = &InteractiveAction{
Expand Down
23 changes: 19 additions & 4 deletions internal/game/session_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ func (a *attempt) execute(line string) (step, error) {
if err != nil || !ran {
return step{}, err
}
if result.Output != "" {
fmt.Fprint(a.Out, result.Output)
if !strings.HasSuffix(result.Output, "\n") {
fmt.Fprintln(a.Out)
if len(result.Transcript) > 0 {
for _, entry := range result.Transcript {
if entry.Error {
a.fail(entry.Text)
} else {
a.printOutput(entry.Text)
}
}
} else {
a.printOutput(result.Output)
}
a.lastOutput = result.Output
unlocked, err := a.recordPractice(result)
Expand Down Expand Up @@ -48,6 +53,16 @@ func (a *attempt) execute(line string) (step, error) {
return finish(completed), nil
}

func (a *attempt) printOutput(output string) {
if output == "" {
return
}
fmt.Fprint(a.Out, output)
if !strings.HasSuffix(output, "\n") {
fmt.Fprintln(a.Out)
}
}

// runCommand executes line and any interactive follow-up. ran is false when
// a recoverable command error was already shown to the player.
func (a *attempt) runCommand(line string) (Execution, bool, error) {
Expand Down
30 changes: 30 additions & 0 deletions internal/game/session_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,33 @@ func TestSessionStaysInCurrentLabWhenSwitchTargetIsUnavailable(t *testing.T) {
}
}
}

func TestCommandListsPrintInOrderAndStillValidate(t *testing.T) {
catalog, item := seamCatalogMission(t, "linux-orientation")
terminal := &bytes.Buffer{}
player := profile.New("tester")
session := Session{
Mission: item,
Player: &player,
Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"),
Out: terminal,
ErrOut: terminal,
Reader: &seamReader{lines: []string{"ls /missing; pwd && ls /missing"}},
Catalog: catalog,
}

result, err := session.Run()
if err != nil || !result.Completed {
t.Fatalf("Run() = %#v, %v; want the pwd output to complete the mission", result, err)
}
transcript := terminal.String()
first := strings.Index(transcript, "ls: /missing: no such file or directory")
output := strings.Index(transcript, "/home/operator\n")
last := strings.LastIndex(transcript, "ls: /missing: no such file or directory")
if first < 0 || output < first || last < output {
t.Fatalf("list output is out of order:\n%s", transcript)
}
if player.Commands["pwd"] != 1 || player.Commands["ls"] != 0 {
t.Errorf("practice = %#v, want only the successful pwd", player.Commands)
}
}
8 changes: 7 additions & 1 deletion internal/profile/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,13 @@ func TestRankAndNextRankShareThresholds(t *testing.T) {
{xp: 0, rank: "Intern", next: "Operator", needed: 100, hasNext: true},
{xp: 100, rank: "Operator", next: "Junior Sysadmin", needed: 150, hasNext: true},
{xp: 649, rank: "Sysadmin", next: "SRE", needed: 1, hasNext: true},
{xp: 1100, rank: "Senior SRE", hasNext: false},
{xp: 1100, rank: "Senior SRE", next: "Staff SRE", needed: 500, hasNext: true},
{xp: 1599, rank: "Senior SRE", next: "Staff SRE", needed: 1, hasNext: true},
{xp: 1600, rank: "Staff SRE", next: "Principal SRE", needed: 550, hasNext: true},
{xp: 2150, rank: "Principal SRE", next: "Distinguished Engineer", needed: 550, hasNext: true},
{xp: 2699, rank: "Principal SRE", next: "Distinguished Engineer", needed: 1, hasNext: true},
{xp: 2700, rank: "Distinguished Engineer", hasNext: false},
{xp: 5000, rank: "Distinguished Engineer", hasNext: false},
}
for _, test := range tests {
player := New("alex")
Expand Down
7 changes: 7 additions & 0 deletions internal/profile/rank.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package profile

// rankThresholds span the whole catalog: Linux alone (1,755 XP) reaches
// Staff SRE, and the top rank needs most of the Docker track (3,085 XP in
// total) without requiring a hint-free run. Thresholds up to Senior SRE are
// unchanged from earlier releases, so no existing profile loses its rank.
var rankThresholds = []struct {
name string
xp int
Expand All @@ -10,6 +14,9 @@ var rankThresholds = []struct {
{name: "Sysadmin", xp: 450},
{name: "SRE", xp: 650},
{name: "Senior SRE", xp: 1100},
{name: "Staff SRE", xp: 1600},
{name: "Principal SRE", xp: 2150},
{name: "Distinguished Engineer", xp: 2700},
}

func (p Profile) Level() int { return p.XP/100 + 1 }
Expand Down
Loading
Loading